Micron Document
🎖️GitЯра🎖️

Commit 7c62ee592d2acd1e9ec8c30cf1cd452b9ca6ac05


Parents : 9665eaf
Author : James Rich <2199651+jamesarich@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-08-24T10:55:52Z
Committer : GitHub <noreply@github.com>
Date : 2026-08-24T10:55:52Z

fix(ui): bound pane content height under the adaptive three-pane scaffold (#6845)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

Changes
Diff

diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/AdaptiveTwoPane.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/AdaptiveTwoPane.kt
index 043115fde4..5e07adc27d 100644
--- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/AdaptiveTwoPane.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/AdaptiveTwoPane.kt
@@ -17,8 +17,11 @@
package org.meshtastic.core.ui.component
import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
@@ -30,6 +33,8 @@ import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2
import androidx.compose.material3.adaptive.layout.AnimatedPane
import androidx.compose.material3.adaptive.layout.PaneAdaptedValue
+import androidx.compose.material3.adaptive.layout.PaneExpansionState
+import androidx.compose.material3.adaptive.layout.PaneScaffoldDirective
import androidx.compose.material3.adaptive.layout.SupportingPaneScaffold
import androidx.compose.material3.adaptive.layout.ThreePaneScaffoldValue
import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective
@@ -38,6 +43,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.movableContentOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import org.meshtastic.core.ui.theme.AppTheme
@@ -54,6 +60,9 @@ import org.meshtastic.core.ui.theme.AppTheme
* When split, the panes are hosted in a [SupportingPaneScaffold] so the divider is a draggable [VerticalDragHandle],
* giving parity with the list-detail / supporting-pane scenes elsewhere in the app. Both slots keep their [ColumnScope]
* receiver, so callers are unchanged.
+ *
+ * The scaffold reports its incoming max height as its own size, so a height-unbounded host (a LazyColumn item, a
+ * scrollable column) would make it echo Constraints.Infinity and crash; a plain [Row] split is used there instead.
*/
@OptIn(ExperimentalMaterial3AdaptiveApi::class)
@Composable
@@ -70,34 +79,28 @@ fun AdaptiveTwoPane(
val firstPane = remember { movableContentOf<ColumnScope>(first) }
val secondPane = remember { movableContentOf<ColumnScope>(second) }
+ // Hoisted above the height branch so a dragged divider survives the host flipping between bounded and
+ // unbounded constraints (the scaffold branch below leaves composition on that flip).
+ val paneExpansionState = rememberPaneExpansionState()
+
if (directive.maxHorizontalPartitions > 1) {
- // Expanded: canonical supporting-pane layout with a draggable divider. Both panes are forced visible because
- // we only reach this branch when the directive allows two partitions.
- SupportingPaneScaffold(
- modifier = modifier,
- directive = directive,
- value =
- ThreePaneScaffoldValue(
- primary = PaneAdaptedValue.Expanded,
- secondary = PaneAdaptedValue.Expanded,
- tertiary = PaneAdaptedValue.Hidden,
- ),
- mainPane = { AnimatedPane { Column { firstPane(this) } } },
- supportingPane = { AnimatedPane { Column { secondPane(this) } } },
- paneExpansionState = rememberPaneExpansionState(),
- paneExpansionDragHandle = { state ->
- val interactionSource = remember { MutableInteractionSource() }
- VerticalDragHandle(
- modifier =
- Modifier.paneExpansionDraggable(
- state = state,
- minTouchTargetSize = LocalMinimumInteractiveComponentSize.current,
- interactionSource = interactionSource,
- ),
- interactionSource = interactionSource,
+ // Expanded: split side-by-side. Only a bounded host may use the pane scaffold; an unbounded one
+ // gets a plain Row that wraps content height instead of echoing infinity (Crashlytics 788308c5).
+ BoxWithConstraints(modifier = modifier) {
+ if (constraints.hasBoundedHeight) {
+ SplitPaneScaffold(
+ directive = directive,
+ firstPane = firstPane,
+ secondPane = secondPane,
+ paneExpansionState = paneExpansionState,
)
- },
- )
+ } else {
+ Row(horizontalArrangement = Arrangement.spacedBy(directive.horizontalPartitionSpacerSize)) {
+ Column(modifier = Modifier.weight(1f)) { firstPane(this) }
+ Column(modifier = Modifier.weight(1f)) { secondPane(this) }
+ }
+ }
+ }
} else {
// Compact / medium: keep both slots stacked in a single column (the supporting content must stay visible on
// phones — this is not a navigable list-detail flow).
@@ -108,6 +111,45 @@ fun AdaptiveTwoPane(
}
}
+/** Test tag for the split divider; lets tests prove the scaffold branch (not the Row fallback) rendered. */
+const val ADAPTIVE_TWO_PANE_DRAG_HANDLE_TAG: String = "AdaptiveTwoPaneDragHandle"
+
+/** Canonical supporting-pane split with a draggable divider; both panes are forced visible. */
+@OptIn(ExperimentalMaterial3AdaptiveApi::class)
+@Composable
+private fun SplitPaneScaffold(
+ directive: PaneScaffoldDirective,
+ firstPane: @Composable (ColumnScope) -> Unit,
+ secondPane: @Composable (ColumnScope) -> Unit,
+ paneExpansionState: PaneExpansionState,
+) {
+ SupportingPaneScaffold(
+ directive = directive,
+ value =
+ ThreePaneScaffoldValue(
+ primary = PaneAdaptedValue.Expanded,
+ secondary = PaneAdaptedValue.Expanded,
+ tertiary = PaneAdaptedValue.Hidden,
+ ),
+ mainPane = { AnimatedPane { Column { firstPane(this) } } },
+ supportingPane = { AnimatedPane { Column { secondPane(this) } } },
+ paneExpansionState = paneExpansionState,
+ paneExpansionDragHandle = { state ->
+ val interactionSource = remember { MutableInteractionSource() }
+ VerticalDragHandle(
+ modifier =
+ Modifier.testTag(ADAPTIVE_TWO_PANE_DRAG_HANDLE_TAG)
+ .paneExpansionDraggable(
+ state = state,
+ minTouchTargetSize = LocalMinimumInteractiveComponentSize.current,
+ interactionSource = interactionSource,
+ ),
+ interactionSource = interactionSource,
+ )
+ },
+ )
+}
+
/** Screenshot-test sample; public so `:screenshot-tests` can render it at compact, medium, and expanded widths. */
@Suppress("MagicNumber")
@Composable

diff --git a/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/AdaptiveTwoPaneUiTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/AdaptiveTwoPaneUiTest.kt
new file mode 100644
index 0000000000..b83508a9bc
--- /dev/null
+++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/component/AdaptiveTwoPaneUiTest.kt
@@ -0,0 +1,121 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.core.ui.component
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Text
+import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
+import androidx.compose.material3.adaptive.currentWindowAdaptiveInfoV2
+import androidx.compose.material3.adaptive.layout.calculatePaneScaffoldDirective
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performTouchInput
+import androidx.compose.ui.test.v2.runComposeUiTest
+import org.meshtastic.core.ui.theme.AppTheme
+import kotlin.math.abs
+import kotlin.test.Test
+import kotlin.test.assertTrue
+
+@OptIn(ExperimentalTestApi::class, ExperimentalMaterial3AdaptiveApi::class)
+class AdaptiveTwoPaneUiTest {
+
+ // Crashlytics 788308c5 (fatal, tablets): the split scaffold echoed the LazyColumn item's infinite
+ // max height as its size ("Size(1608 x 2147483647) is out of range"). Must render, not crash.
+ @Test
+ fun splitPaneInsideLazyColumnItemRendersBothPanes() = runComposeUiTest {
+ var horizontalPartitions = 0
+ setContent {
+ AppTheme {
+ horizontalPartitions =
+ calculatePaneScaffoldDirective(currentWindowAdaptiveInfoV2()).maxHorizontalPartitions
+ LazyColumn {
+ item { AdaptiveTwoPane(first = { Text("first pane") }, second = { Text("second pane") }) }
+ }
+ }
+ }
+
+ // The default test window is expanded-width; without this the regression path is not exercised.
+ assertTrue(horizontalPartitions > 1, "expected an expanded-width test window")
+ onNodeWithText("first pane").assertIsDisplayed()
+ onNodeWithText("second pane").assertIsDisplayed()
+ // No drag handle: the unbounded host must get the Row fallback, not the scaffold.
+ onNodeWithTag(ADAPTIVE_TWO_PANE_DRAG_HANDLE_TAG).assertDoesNotExist()
+ }
+
+ @Test
+ fun splitPaneInBoundedHostRendersBothPanes() = runComposeUiTest {
+ setContent {
+ AppTheme {
+ Box(modifier = Modifier.fillMaxSize()) {
+ AdaptiveTwoPane(first = { Text("first pane") }, second = { Text("second pane") })
+ }
+ }
+ }
+
+ onNodeWithText("first pane").assertIsDisplayed()
+ onNodeWithText("second pane").assertIsDisplayed()
+ // The drag handle proves the bounded host kept the SupportingPaneScaffold branch.
+ onNodeWithTag(ADAPTIVE_TWO_PANE_DRAG_HANDLE_TAG).assertExists()
+ }
+
+ // The pane expansion state is hoisted above the height branch, so a divider the user dragged must
+ // survive the host flipping to unbounded constraints (scaffold disposed) and back.
+ @Test
+ fun dividerPositionSurvivesBoundedUnboundedRoundTrip() = runComposeUiTest {
+ var bounded by mutableStateOf(true)
+ setContent {
+ AppTheme {
+ val hostModifier =
+ if (bounded) Modifier.fillMaxSize() else Modifier.verticalScroll(rememberScrollState())
+ Box(modifier = hostModifier) {
+ AdaptiveTwoPane(first = { Text("first pane") }, second = { Text("second pane") })
+ }
+ }
+ }
+
+ val handle = onNodeWithTag(ADAPTIVE_TWO_PANE_DRAG_HANDLE_TAG)
+ val initialX = handle.fetchSemanticsNode().boundsInRoot.center.x
+ handle.performTouchInput {
+ down(center)
+ moveBy(Offset(-200f, 0f))
+ up()
+ }
+ waitForIdle()
+ val draggedX = handle.fetchSemanticsNode().boundsInRoot.center.x
+ assertTrue(abs(draggedX - initialX) > 50f, "expected the drag to move the divider")
+
+ bounded = false
+ waitForIdle()
+ handle.assertDoesNotExist()
+
+ bounded = true
+ waitForIdle()
+ val restoredX = handle.fetchSemanticsNode().boundsInRoot.center.x
+ assertTrue(abs(restoredX - draggedX) < 3f, "divider was at $draggedX but came back at $restoredX")
+ }
+}

Served by rngit 1.5.2 - Generated in 0.07s